module Argser

    class Parser

        @tokens
        @options
        @params
        @remaining

        #Available types (:integer, :float, :boolean, :alpanumeric, :letters, :words, :range, :interval and :string)
        TYPES = {
            integer:      [/^\s*\d+\s*$/,                                                                            lambda {|value, match, default| value.to_i || 0}],
            float:        [/^\s*\d+\.\d+\s*$/,                                                                       lambda {|value, match, default| value.to_f || 0.0}],
            boolean:      [/^\s*$/,                                                                                  lambda {|value, match, default| !default || false}],
            letters:      [/^\s*[A-Za-z]+\s*$/,                                                                      lambda {|value, match, default| (!default) ? value.strip : (value || '').strip}],
            alphanumeric: [/^\s*\w+\s*$/,                                                                            lambda {|value, match, default| (!default) ? value.strip : (value || '').strip}],
            words:        [/^\s*[A-Za-z]+(?:\s+[A-Za-z]+)*\s*$/,                                                     lambda {|value, match, default| (!default) ? value.strip : (value || '').strip}],
            range:        [/^\s*(?:(\d+)\.\.(\d+)|(\d*)\.\.|\.\.(\d+))\s*$/,                                         lambda {|value, match, default| (!default) ? [(match[0] || match[3]).to_i, (match[2] || match[4]).to_i] : value || [0,0]}],
            interval:     [/^\s*(?:(\d+(?:\.\d+)?)\.\.(\d+(?:\.\d+)?)|(\d*(?:\.\d+)?)\.\.|\.\.(\d+(?:\.\d+)?))\s*$/, lambda {|value, match, default| (!default) ? [(match[0] || match[3]).to_f, (match[2] || match[4]).to_f] : value || [0.0,0.0]}],
            string:       [/^.*$/,                                                                                   lambda {|value, match, default| value || ''}]
        }

        #Initialize the parser
        def initialize(config)
            @tokens, @options, @params, @remaining = {}, {}, {}, {}
            init_options(config[:options] || {})
            init_params(config[:params] || {})
            init_remaining(config[:remaining] || {})
        end

        #Parse an array with arguments and associate each element to one of the parameters or to the remaining array
        def parse(args)
            begin
                #Identify each element in the array of arguments
                last_param = nil
                args.each do |arg|
                    if (arg =~ /^--?\w+$/)
                        arg = arg.downcase

                        raise_error(@options[:unknown], arg) unless @tokens.has_key? arg
                        param = @params[@tokens[arg]]
                        param[:given] = arg
                        if (@params[@tokens[arg]][:type] != :boolean)
                            last_param = param
                        end

                        #If parameter :help was given and the parser is supposed to automatically display the help, do so
                        help if (param? :help) and (@options[:help])
                    else
                        if (last_param)
                            last_param[:value] = arg
                            last_param = nil
                        else
                            @remaining[:value] = [] if @remaining[:given] == 0
                            @remaining[:given] += 1
                            @remaining[:value].push arg
                        end
                    end
                end

                #Iterate over all parameters and process their values
                @params.each do |token, param|
                    raise_error(param[:errors][:required], "--#{param[:token].to_s}") unless param?(token) or !param[:required]
                    validate_param(param)
                    sanitize(param)
                end

                #Process the value of the remaining array
                raise_error(@remaining[:errors][:required]) unless (@remaining[:minimum] <= remaining?) and (remaining? <= @remaining[:maximum])
                validate_remaining()
                sanitize(@remaining)

                #Invoke all callbacks
                invoke_callbacks
            rescue ParserError => error
                show_error error.message
            end
        end

        #Check if a parameter was given
        def param?(param)
            (@tokens.has_key? param) ? !!@params[@tokens[param]][:given] : false
        end

        #Return the value of a parameter
        def param(param)
            (@tokens.has_key? param) ? @params[@tokens[param]][:value] : ''
        end

        #Returns the number of remaining strings that were found
        def remaining?()
            @remaining[:given]
        end

        #Return the remaining values
        def remaining()
            @remaining[:value]
        end

        #Return both parameters and remaining values in a hash
        def dump
            values = {}
            @params.each do |token, param|
                values[token] = param[:value]
            end
            {params: values, remaining: @remaining[:value]}
        end

        #Display the help and exit
        def help
            puts "#{@options[:description]}\n\n" unless @options[:description].empty?
            puts "#{@options[:usage]}\n\n"  unless @options[:usage].empty?
            @params.each do |token, param|
                token = "--#{token.to_s}"
                shortcut = (param[:shortcut].is_a? Symbol) ? "-#{param[:shortcut]}" : ''
                help = param[:help]
                puts "   #{shortcut.ljust(5, ' ')}#{token.ljust(20, ' ')}#{help}"
            end
            puts "\n   #{''.ljust(25, ' ')}#{@remaining[:help]}" unless @remaining[:help].empty?
            puts "\n#{@options[:details]}" unless @options[:details].empty?
            exit
        end

        #Print the error and how to find more information
        def show_error(error)
            puts error
            puts @options[:info] unless @options[:info].empty?
            exit
        end

        private

            #Available general options:
            #   :description    A string containing the description of the application (default: "")
            #   :usage          A string describing how to use the application (default: "")
            #   :details        A string containing details about the application (default: "")
            #   :info           A string printer bellow errors indicating how to find more information about them (default: "For more information try '--help'")
            #   :unknown        A string with the error to show when a parameter is not known (default: "Unknown parameter '$p'")
            #   :fatal          A string with the error to show when an internal error is found while parsing parameters (default: "An internal error was found")
            #   :help           A boolean value indicating if the parser should automatically display the help if parameter :help is given (default: true)

            #Save all options for the parser
            def init_options(options={})
                @options = {
                    description: "",
                    usage:       "",
                    details:     "",
                    info:        "For more information try '--help'",
                    unknown:     "ERROR: Unknown parameter '$p'",
                    fatal:       "ERROR: An internal error was found",
                    help:        true
                }.merge options
            end

            #Available parameter options:
            #   :shortcut    A symbol used as a shortcut for this parameter (default: nil)
            #   :type        A symbol indicating the type of this parameter (default: :string)
            #   :default     The default value if not other value is provided (default: '')
            #   :required    A boolean value indicating if this parameter is required or not
            #   :sanitize    A function to sanitize the value given (return the identifier of an error to show it or the sanitized value the parser should save)
            #   :callback    A function to call to do something with the parameter
            #   :help        A string with the message to print as the help for this parameter
            #   :errors    A hash with all available errors to show where the key is used as an identifier (:help, :type and :required are created by default)

            #Save all parameters the parser should recognize
            def init_params(params={})
                #If the parser should recognize :help, add it
                if (!params.has_key? :help) and (@options[:help])
                    params[:help] = {
                        shortcut: :h,
                        type:     :boolean,
                        default:  false,
                        help: 'Display this help and exit',
                        errors: {}
                    }
                end

                #Save each param and overwrite default options
                params.each do |token, param|
                    @params[token] = {
                        shortcut: nil,
                        type:     :string,
                        default:  '',
                        required: false,
                        sanitize: lambda {|value, default| value},
                        callback: lambda {|value, default|},
                        help:     "",
                        errors: {}
                    }.merge param

                    @params[token][:token] = token
                    @params[token][:value] = @params[token][:default]
                    @params[token][:given] = false

                    #Save errors, make sure :type and :required is available
                    @params[token][:errors] = {
                        type:     "ERROR: Invalid value '$v' for parameter '$p'",
                        required: "ERROR: Parameter '$p' is required"
                    }.merge @params[token][:errors]

                    #Register all tokens for the parameter
                    @tokens[token] = token
                    @tokens["--#{token}"] = token
                    if (param[:shortcut].is_a? Symbol)
                        @tokens[param[:shortcut]] = token
                        @tokens["-#{param[:shortcut]}"] = token
                    end
                end
            end

            #Available remaining array options:
            #   :type        A symbol indicating the type of this parameter (default: :string)
            #   :default     The default value if not other value is provided (default: '')
            #   :minimum     Minimum number of strings allowed (default: 0)
            #   :maximum     Maximum number of strings allowed (default: Infinity)
            #   :sanitize    A function to sanitize the value given (return the identifier of an error to show it or the sanitized value the parser should save)
            #   :callback    A function to call to do something with the parameter
            #   :help        A string with the message to print as the help for the remaining strings
            #   :errors    A hash with all available errors to show where the key is used as an identifier (:type and :required are created by default)

            #Save options for remaining strings
            def init_remaining(remaining={})
                @remaining = {
                    type:     :string,
                    default:  [],
                    minimum:  0,
                    maximum:  Float::INFINITY,
                    sanitize: lambda {|value, default| value},
                    callback: lambda {|value, default|},
                    help: "",
                    errors: {}
                }.merge remaining

                @remaining[:value] = @remaining[:default]
                @remaining[:given] = 0

                #Save errors, make sure, :type and :required is available
                @remaining[:errors] = {
                    type:     "ERROR: Invalid value '$v'",
                    required: "ERROR: Incorrect number of remaining arguments"
                }.merge @remaining[:errors]
            end

            #Validate the value of a parameter based on its type
            def validate_param(param)
                #If the type is a symbol, it must be a valid type
                if (param[:type].is_a? Symbol)
                    type = TYPES[param[:type]]
                    raise_error(param[:errors][:type], param[:token], param[:value]) if (param[:given]) and (param[:value].is_a? String) and !(match = type[0].match param[:value])
                    #Convert value to the correct type
                    param[:value] = type[1].call(param[:value], match, !param[:given])
                else #Otherwise it must be a regexp and the value must match
                    raise_error(param[:errors][:type], param[:token], param[:value]) unless param[:value] =~ param[:type]
                end
            end

            #Validate the value of the remaining array based on its type
            def validate_remaining()
                #If the type is a symbol, it must be a valid type
                if (@remaining[:type].is_a? Symbol)
                    type = TYPES[@remaining[:type]]
                    @remaining[:value].each_with_index do |value, i|
                        raise_error(@remaining[:errors][:type], @remaining[:token], value) unless (@remaining[:given] == 0) or (match = type[0].match value)
                        #Convert value to the correct type
                        @remaining[:value][i] = type[1].call(value, match, @remaining[:given] == 0)
                    end
                else #Otherwise it must be a regexp and the values must match
                    @remaining[:value].each do |value|
                        raise_error(@remaining[:errors][:type], @remaining[:token], value) unless value =~ @remaining[:type]
                    end
                end
            end

            #Sanitize the value of the parameter, if a symbol is returned the associated error is shown
            def sanitize(param)
                value = param[:sanitize].call(param[:value], !param[:given])
                raise_error(param[:errors][value], param[:token], param[:value]) if value.is_a? Symbol
                #Update the value
                param[:value] = value
            end

            #Invoke parameters and remaining array callbacks
            def invoke_callbacks
                @params.each do |token, param|
                    param[:callback].call(param[:value], !param[:given])
                end
                @remaining[:callback].call(@remaining[:value], !@remaining[:given])
            end

            #Raise an error
            def raise_error(message, token='', value='')
                raise ParserError.new(message || @options[:fatal], token, value)
            end

            #Parser errors used to display errors ($p is replaced by the parameter used by the user, $v is replaced by the value given)
            class ParserError < Exception
                attr_reader :message
                def initialize(message, token='', value='')
                    @message = message.gsub('$p', token.to_s).gsub('$v', value.to_s)
                end
            end

    end

end